//go:build !windows
// +build !windows

package file

import (
	"archive/zip"
	"context"
	"crypto/sha256"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"path"
	"path/filepath"
	"strings"
	"testing"

	"github.com/go-test/deep"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func equal(r1, r2 io.Reader) (bool, error) {
	w1 := sha256.New()
	w2 := sha256.New()
	n1, err1 := io.Copy(w1, r1)
	if err1 != nil {
		return false, err1
	}
	n2, err2 := io.Copy(w2, r2)
	if err2 != nil {
		return false, err2
	}

	var b1, b2 [sha256.Size]byte
	copy(b1[:], w1.Sum(nil))
	copy(b2[:], w2.Sum(nil))

	return n1 != n2 || b1 == b2, nil
}

func TestUnzipToDir(t *testing.T) {
	cwd, err := os.Getwd()
	if err != nil {
		t.Fatal(err)
	}

	goldenRootDir := filepath.Join(cwd, "test-fixtures")
	sourceDirPath := path.Join(goldenRootDir, "zip-source")
	archiveFilePath := setupZipFileTest(t, sourceDirPath, false)

	unzipDestinationDir := t.TempDir()

	t.Logf("content path: %s", unzipDestinationDir)

	expectedPaths := len(expectedZipArchiveEntries)
	observedPaths := 0

	err = UnzipToDir(context.Background(), archiveFilePath, unzipDestinationDir)
	if err != nil {
		t.Fatalf("unable to unzip archive: %+v", err)
	}

	// compare the source dir tree and the unzipped tree
	err = filepath.Walk(unzipDestinationDir,
		func(path string, info os.FileInfo, err error) error {
			// We don't unzip the root archive dir, since there's no archive entry for it
			if path != unzipDestinationDir {
				t.Logf("unzipped path: %s", path)
				observedPaths++
			}

			if err != nil {
				t.Fatalf("this should not happen")
				return err
			}

			goldenPath := filepath.Join(sourceDirPath, strings.TrimPrefix(path, unzipDestinationDir))

			if info.IsDir() {
				i, err := os.Stat(goldenPath)
				if err != nil {
					t.Fatalf("unable to stat golden path: %+v", err)
				}
				if !i.IsDir() {
					t.Fatalf("mismatched file types: %s", goldenPath)
				}
				return nil
			}

			// this is a file, not a dir...

			testFile, err := os.Open(path)
			if err != nil {
				t.Fatalf("unable to open test file=%s :%+v", path, err)
			}

			goldenFile, err := os.Open(goldenPath)
			if err != nil {
				t.Fatalf("unable to open golden file=%s :%+v", goldenPath, err)
			}

			same, err := equal(testFile, goldenFile)
			if err != nil {
				t.Fatalf("could not compare files (%s, %s): %+v", goldenPath, path, err)
			}

			if !same {
				t.Errorf("paths are not the same (%s, %s)", goldenPath, path)
			}

			return nil
		})

	if err != nil {
		t.Errorf("failed to walk dir: %+v", err)
	}

	if observedPaths != expectedPaths {
		t.Errorf("missed test paths: %d != %d", observedPaths, expectedPaths)
	}
}

func TestContentsFromZip(t *testing.T) {
	tests := []struct {
		name        string
		archivePrep func(tb testing.TB) string
	}{
		{
			name:        "standard, non-nested zip",
			archivePrep: prepZipSourceFixture,
		},
		{
			name:        "zip with prepended bytes",
			archivePrep: prependZipSourceFixtureWithString(t, "junk at the beginning of the file..."),
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			archivePath := test.archivePrep(t)
			expected := zipSourceFixtureExpectedContents()

			var paths []string
			for p := range expected {
				paths = append(paths, p)
			}

			actual, err := ContentsFromZip(context.Background(), archivePath, paths...)
			if err != nil {
				t.Fatalf("unable to extract from unzip archive: %+v", err)
			}

			assertZipSourceFixtureContents(t, actual, expected)
		})
	}
}

func prependZipSourceFixtureWithString(tb testing.TB, value string) func(tb testing.TB) string {
	if len(value) == 0 {
		tb.Fatalf("no bytes given to prefix")
	}
	return func(t testing.TB) string {
		archivePath := prepZipSourceFixture(t)

		// create a temp file
		tmpFile, err := os.CreateTemp(tb.TempDir(), "syft-ziputil-prependZipSourceFixtureWithString-")
		if err != nil {
			t.Fatalf("unable to create tempfile: %+v", err)
		}
		defer tmpFile.Close()

		// write value to the temp file
		if _, err := tmpFile.WriteString(value); err != nil {
			t.Fatalf("unable to write to tempfile: %+v", err)
		}

		// open the original archive
		sourceFile, err := os.Open(archivePath)
		if err != nil {
			t.Fatalf("unable to read source file: %+v", err)
		}

		// copy all contents from the archive to the temp file
		if _, err := io.Copy(tmpFile, sourceFile); err != nil {
			t.Fatalf("unable to copy source to dest: %+v", err)
		}

		sourceFile.Close()

		// remove the original archive and replace it with the temp file
		if err := os.Remove(archivePath); err != nil {
			t.Fatalf("unable to remove original source archive (%q): %+v", archivePath, err)
		}

		if err := os.Rename(tmpFile.Name(), archivePath); err != nil {
			t.Fatalf("unable to move new archive to old path (%q): %+v", tmpFile.Name(), err)
		}

		return archivePath
	}
}

func prepZipSourceFixture(t testing.TB) string {
	t.Helper()
	archivePrefix := path.Join(t.TempDir(), "syft-ziputil-prepZipSourceFixture-")

	// the zip utility will add ".zip" to the end of the given name
	archivePath := archivePrefix + ".zip"

	t.Logf("archive path: %s", archivePath)

	createZipArchive(t, "zip-source", archivePrefix, false)

	return archivePath
}

func zipSourceFixtureExpectedContents() map[string]string {
	return map[string]string{
		filepath.Join("some-dir", "a-file.txt"): "A file! nice!",
		filepath.Join("b-file.txt"):             "B file...",
	}
}

func assertZipSourceFixtureContents(t testing.TB, actual map[string]string, expected map[string]string) {
	t.Helper()
	diffs := deep.Equal(actual, expected)
	if len(diffs) > 0 {
		for _, d := range diffs {
			t.Errorf("diff: %+v", d)
		}

		b, err := json.MarshalIndent(actual, "", "  ")
		if err != nil {
			t.Fatalf("can't show results: %+v", err)
		}

		t.Errorf("full result: %s", string(b))
	}
}

// looks like there isn't a helper for this yet? https://github.com/stretchr/testify/issues/497
func assertErrorAs(expectedErr interface{}) assert.ErrorAssertionFunc {
	return func(t assert.TestingT, actualErr error, i ...interface{}) bool {
		return errors.As(actualErr, &expectedErr)
	}
}

func TestSafeJoin(t *testing.T) {
	tests := []struct {
		prefix       string
		args         []string
		expected     string
		errAssertion assert.ErrorAssertionFunc
	}{
		// go cases...
		{
			prefix: "/a/place",
			args: []string{
				"somewhere/else",
			},
			expected:     "/a/place/somewhere/else",
			errAssertion: assert.NoError,
		},
		{
			prefix: "/a/place",
			args: []string{
				"somewhere/../else",
			},
			expected:     "/a/place/else",
			errAssertion: assert.NoError,
		},
		{
			prefix: "/a/../place",
			args: []string{
				"somewhere/else",
			},
			expected:     "/place/somewhere/else",
			errAssertion: assert.NoError,
		},
		// zip slip examples....
		{
			prefix: "/a/place",
			args: []string{
				"../../../etc/passwd",
			},
			expected:     "",
			errAssertion: assertErrorAs(&errZipSlipDetected{}),
		},
		{
			prefix: "/a/place",
			args: []string{
				"../",
				"../",
			},
			expected:     "",
			errAssertion: assertErrorAs(&errZipSlipDetected{}),
		},
		{
			prefix: "/a/place",
			args: []string{
				"../",
			},
			expected:     "",
			errAssertion: assertErrorAs(&errZipSlipDetected{}),
		},
	}

	for _, test := range tests {
		t.Run(fmt.Sprintf("%+v:%+v", test.prefix, test.args), func(t *testing.T) {
			actual, err := SafeJoin(test.prefix, test.args...)
			test.errAssertion(t, err)
			assert.Equal(t, test.expected, actual)
		})
	}
}

// TestSymlinkProtection demonstrates that SafeJoin protects against symlink-based
// directory traversal attacks by validating that archive entry paths cannot escape
// the extraction directory.
func TestSafeJoin_SymlinkProtection(t *testing.T) {
	tests := []struct {
		name        string
		archivePath string // Path as it would appear in the archive
		expectError bool
		description string
	}{
		{
			name:        "path traversal via ../",
			archivePath: "../../../outside/file.txt",
			expectError: true,
			description: "Archive entry with ../ trying to escape extraction dir",
		},
		{
			name:        "absolute path symlink target",
			archivePath: "../../../sensitive.txt",
			expectError: true,
			description: "Simulates symlink pointing outside via relative path",
		},
		{
			name:        "safe relative path within extraction dir",
			archivePath: "subdir/safe.txt",
			expectError: false,
			description: "Normal file path that stays within extraction directory",
		},
		{
			name:        "safe path with internal ../",
			archivePath: "dir1/../dir2/file.txt",
			expectError: false,
			description: "Path with ../ that still resolves within extraction dir",
		},
		{
			name:        "deeply nested traversal",
			archivePath: "../../../../../../tmp/evil.txt",
			expectError: true,
			description: "Multiple levels of ../ trying to escape",
		},
		{
			name:        "single parent directory escape",
			archivePath: "../",
			expectError: true,
			description: "Simple one-level escape attempt",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			// Create temp directories to simulate extraction scenario
			tmpDir := t.TempDir()
			extractDir := filepath.Join(tmpDir, "extract")
			outsideDir := filepath.Join(tmpDir, "outside")

			require.NoError(t, os.MkdirAll(extractDir, 0755))
			require.NoError(t, os.MkdirAll(outsideDir, 0755))

			// Create a file outside extraction dir that an attacker might target
			outsideFile := filepath.Join(outsideDir, "sensitive.txt")
			require.NoError(t, os.WriteFile(outsideFile, []byte("sensitive data"), 0644))

			// Test SafeJoin - this is what happens when processing archive entries
			result, err := SafeJoin(extractDir, tt.archivePath)

			if tt.expectError {
				// Should block malicious paths
				require.Error(t, err, "Expected SafeJoin to reject malicious path")
				var zipSlipErr *errZipSlipDetected
				assert.ErrorAs(t, err, &zipSlipErr, "Error should be errZipSlipDetected type")
				assert.Empty(t, result, "Result should be empty for blocked paths")
			} else {
				// Should allow safe paths
				require.NoError(t, err, "Expected SafeJoin to allow safe path")
				assert.NotEmpty(t, result, "Result should not be empty for safe paths")
				assert.True(t, strings.HasPrefix(filepath.Clean(result), filepath.Clean(extractDir)),
					"Safe path should resolve within extraction directory")
			}
		})
	}
}

// TestUnzipToDir_SymlinkAttacks tests UnzipToDir function with malicious ZIP archives
// containing symlink entries that attempt path traversal attacks.
//
// EXPECTED BEHAVIOR: UnzipToDir should either:
//  1. Detect and reject symlinks explicitly with a security error, OR
//  2. Extract them safely (library converts symlinks to regular files)
func TestUnzipToDir_SymlinkAttacks(t *testing.T) {
	tests := []struct {
		name        string
		symlinkName string
		fileName    string
		errContains string
	}{
		{
			name:        "direct symlink to outside directory",
			symlinkName: "evil_link",
			fileName:    "evil_link/payload.txt",
			errContains: "not a directory", // attempt to write through symlink leaf (which is not a directory)
		},
		{
			name:        "directory symlink attack",
			symlinkName: "safe_dir/link",
			fileName:    "safe_dir/link/payload.txt",
			errContains: "not a directory", // attempt to write through symlink (which is not a directory)
		},
		{
			name:        "symlink without payload file",
			symlinkName: "standalone_link",
			fileName:    "", // no payload file
			errContains: "", // no error expected, symlink without payload is safe
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			tempDir := t.TempDir()

			// create outside target directory
			outsideDir := filepath.Join(tempDir, "outside_target")
			require.NoError(t, os.MkdirAll(outsideDir, 0755))

			// create extraction directory
			extractDir := filepath.Join(tempDir, "extract")
			require.NoError(t, os.MkdirAll(extractDir, 0755))

			maliciousZip := createMaliciousZipWithSymlink(t, tempDir, tt.symlinkName, outsideDir, tt.fileName)

			err := UnzipToDir(context.Background(), maliciousZip, extractDir)

			// check error expectations
			if tt.errContains != "" {
				require.Error(t, err)
				require.Contains(t, err.Error(), tt.errContains)
			} else {
				require.NoError(t, err)
			}

			analyzeExtractionDirectory(t, extractDir)

			// check if payload file escaped extraction directory
			if tt.fileName != "" {
				maliciousFile := filepath.Join(outsideDir, filepath.Base(tt.fileName))
				checkFileOutsideExtraction(t, maliciousFile)
			}

			// check if symlink was created pointing outside
			symlinkPath := filepath.Join(extractDir, tt.symlinkName)
			checkSymlinkCreation(t, symlinkPath, extractDir, outsideDir)
		})
	}
}

// TestContentsFromZip_SymlinkAttacks tests the ContentsFromZip function with malicious
// ZIP archives containing symlink entries.
//
// EXPECTED BEHAVIOR: ContentsFromZip should either:
//  1. Reject symlinks explicitly, OR
//  2. Return empty content for symlinks (library behavior)
//
// Though ContentsFromZip doesn't write to disk, but if symlinks are followed, it could read sensitive
// files from outside the archive.
func TestContentsFromZip_SymlinkAttacks(t *testing.T) {
	tests := []struct {
		name          string
		symlinkName   string
		symlinkTarget string
		requestPath   string
		errContains   string
	}{
		{
			name:          "request symlink entry directly",
			symlinkName:   "evil_link",
			symlinkTarget: "/etc/hosts", // attempt to read sensitive file
			requestPath:   "evil_link",
			errContains:   "", // no error expected - library returns symlink metadata
		},
		{
			name:          "symlink in nested directory",
			symlinkName:   "nested/link",
			symlinkTarget: "/etc/hosts",
			requestPath:   "nested/link",
			errContains:   "", // no error expected - library returns symlink metadata
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			tempDir := t.TempDir()

			// create malicious ZIP with symlink entry (no payload file needed)
			maliciousZip := createMaliciousZipWithSymlink(t, tempDir, tt.symlinkName, tt.symlinkTarget, "")

			contents, err := ContentsFromZip(context.Background(), maliciousZip, tt.requestPath)

			// check error expectations
			if tt.errContains != "" {
				require.Error(t, err)
				require.Contains(t, err.Error(), tt.errContains)
				return
			}
			require.NoError(t, err)

			// verify symlink handling - library should return symlink target as content (metadata)
			content, found := contents[tt.requestPath]
			require.True(t, found, "symlink entry should be found in results")

			// verify symlink was NOT followed (content should be target path or empty)
			if content != "" && content != tt.symlinkTarget {
				// content is not empty and not the symlink target - check if actual file was read
				if _, statErr := os.Stat(tt.symlinkTarget); statErr == nil {
					targetContent, readErr := os.ReadFile(tt.symlinkTarget)
					if readErr == nil && string(targetContent) == content {
						t.Errorf("critical issue!... symlink was FOLLOWED and external file content was read!")
						t.Logf("  symlink: %s → %s", tt.requestPath, tt.symlinkTarget)
						t.Logf("  content length: %d bytes", len(content))
					}
				}
			}
		})
	}
}

// TestExtractFromZipToUniqueTempFile_SymlinkAttacks tests the ExtractFromZipToUniqueTempFile
// function with malicious ZIP archives containing symlink entries.
//
// EXPECTED BEHAVIOR: ExtractFromZipToUniqueTempFile should either:
//  1. Reject symlinks explicitly, OR
//  2. Extract them safely (library converts to empty files, filepath.Base sanitizes names)
//
// This function uses filepath.Base() on the archive entry name for temp file prefix and
// os.CreateTemp() which creates files in the specified directory, so it should be protected.
func TestExtractFromZipToUniqueTempFile_SymlinkAttacks(t *testing.T) {
	tests := []struct {
		name          string
		symlinkName   string
		symlinkTarget string
		requestPath   string
		errContains   string
	}{
		{
			name:          "extract symlink entry to temp file",
			symlinkName:   "evil_link",
			symlinkTarget: "/etc/passwd",
			requestPath:   "evil_link",
			errContains:   "", // no error expected - library extracts symlink metadata
		},
		{
			name:          "extract nested symlink",
			symlinkName:   "nested/dir/link",
			symlinkTarget: "/tmp/outside",
			requestPath:   "nested/dir/link",
			errContains:   "", // no error expected
		},
		{
			name:          "extract path traversal symlink name",
			symlinkName:   "../../escape",
			symlinkTarget: "/tmp/outside",
			requestPath:   "../../escape",
			errContains:   "", // no error expected - filepath.Base sanitizes name
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			tempDir := t.TempDir()

			maliciousZip := createMaliciousZipWithSymlink(t, tempDir, tt.symlinkName, tt.symlinkTarget, "")

			// create temp directory for extraction
			extractTempDir := filepath.Join(tempDir, "temp_extract")
			require.NoError(t, os.MkdirAll(extractTempDir, 0755))

			openers, err := ExtractFromZipToUniqueTempFile(context.Background(), maliciousZip, extractTempDir, tt.requestPath)

			// check error expectations
			if tt.errContains != "" {
				require.Error(t, err)
				require.Contains(t, err.Error(), tt.errContains)
				return
			}
			require.NoError(t, err)

			// verify symlink was extracted
			opener, found := openers[tt.requestPath]
			require.True(t, found, "symlink entry should be extracted")

			// verify temp file is within temp directory
			tempFilePath := opener.path
			cleanTempDir := filepath.Clean(extractTempDir)
			cleanTempFile := filepath.Clean(tempFilePath)
			require.True(t, strings.HasPrefix(cleanTempFile, cleanTempDir),
				"temp file must be within temp directory: %s not in %s", cleanTempFile, cleanTempDir)

			// verify symlink was NOT followed (content should be target path or empty)
			f, openErr := opener.Open()
			require.NoError(t, openErr)
			defer f.Close()

			content, readErr := io.ReadAll(f)
			require.NoError(t, readErr)

			// check if symlink was followed (content matches actual file)
			if len(content) > 0 && string(content) != tt.symlinkTarget {
				if _, statErr := os.Stat(tt.symlinkTarget); statErr == nil {
					targetContent, readErr := os.ReadFile(tt.symlinkTarget)
					if readErr == nil && string(targetContent) == string(content) {
						t.Errorf("critical issue!... symlink was FOLLOWED and external file content was copied!")
						t.Logf("  symlink: %s → %s", tt.requestPath, tt.symlinkTarget)
						t.Logf("  content length: %d bytes", len(content))
					}
				}
			}
		})
	}
}

// forensicFindings contains the results of analyzing an extraction directory
type forensicFindings struct {
	symlinksFound          []forensicSymlink
	regularFiles           []string
	directories            []string
	symlinkVulnerabilities []string
}

type forensicSymlink struct {
	path              string
	target            string
	escapesExtraction bool
	resolvedPath      string
}

// analyzeExtractionDirectory walks the extraction directory and detects symlinks that point
// outside the extraction directory. It is silent unless vulnerabilities are found.
func analyzeExtractionDirectory(t *testing.T, extractDir string) forensicFindings {
	t.Helper()

	findings := forensicFindings{}

	filepath.Walk(extractDir, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			// only log if there's an error walking the directory
			t.Logf("Error walking %s: %v", path, err)
			return nil
		}

		relPath := strings.TrimPrefix(path, extractDir+"/")
		if relPath == "" {
			relPath = "."
		}

		// use Lstat to detect symlinks without following them
		linfo, lerr := os.Lstat(path)
		if lerr == nil && linfo.Mode()&os.ModeSymlink != 0 {
			target, _ := os.Readlink(path)

			// resolve to see where it actually points
			var resolvedPath string
			var escapesExtraction bool

			if filepath.IsAbs(target) {
				// absolute symlink
				resolvedPath = target
				cleanExtractDir := filepath.Clean(extractDir)
				escapesExtraction = !strings.HasPrefix(filepath.Clean(target), cleanExtractDir)

				if escapesExtraction {
					t.Errorf("critical issue!... absolute symlink created: %s → %s", relPath, target)
					t.Logf("  this symlink points outside the extraction directory")
					findings.symlinkVulnerabilities = append(findings.symlinkVulnerabilities,
						fmt.Sprintf("absolute symlink: %s → %s", relPath, target))
				}
			} else {
				// relative symlink - resolve it
				resolvedPath = filepath.Join(filepath.Dir(path), target)
				cleanResolved := filepath.Clean(resolvedPath)
				cleanExtractDir := filepath.Clean(extractDir)

				escapesExtraction = !strings.HasPrefix(cleanResolved, cleanExtractDir)

				if escapesExtraction {
					t.Errorf("critical issue!... symlink escapes extraction dir: %s → %s", relPath, target)
					t.Logf("  symlink resolves to: %s (outside extraction directory)", cleanResolved)
					findings.symlinkVulnerabilities = append(findings.symlinkVulnerabilities,
						fmt.Sprintf("relative symlink escape: %s → %s (resolves to %s)", relPath, target, cleanResolved))
				}
			}

			findings.symlinksFound = append(findings.symlinksFound, forensicSymlink{
				path:              relPath,
				target:            target,
				escapesExtraction: escapesExtraction,
				resolvedPath:      resolvedPath,
			})
		} else {
			// regular file or directory - collect silently
			if info.IsDir() {
				findings.directories = append(findings.directories, relPath)
			} else {
				findings.regularFiles = append(findings.regularFiles, relPath)
			}
		}
		return nil
	})

	return findings
}

// checkFileOutsideExtraction checks if a file was written outside the extraction directory.
// Returns true if the file exists (vulnerability), false otherwise. Silent on success.
func checkFileOutsideExtraction(t *testing.T, filePath string) bool {
	t.Helper()

	if stat, err := os.Stat(filePath); err == nil {
		content, _ := os.ReadFile(filePath)
		t.Errorf("critical issue!... file written OUTSIDE extraction directory!")
		t.Logf("  location: %s", filePath)
		t.Logf("  size: %d bytes", stat.Size())
		t.Logf("  content: %s", string(content))
		t.Logf("  ...this means an attacker can write files to arbitrary locations on the filesystem")
		return true
	}
	// no file found outside extraction directory...
	return false
}

// checkSymlinkCreation verifies if a symlink was created at the expected path and reports
// whether it points outside the extraction directory. Silent unless a symlink is found.
func checkSymlinkCreation(t *testing.T, symlinkPath, extractDir, expectedTarget string) bool {
	t.Helper()

	if linfo, err := os.Lstat(symlinkPath); err == nil {
		if linfo.Mode()&os.ModeSymlink != 0 {
			target, _ := os.Readlink(symlinkPath)

			if expectedTarget != "" && target == expectedTarget {
				t.Errorf("critical issue!... symlink pointing outside extraction dir was created!")
				t.Logf("  Symlink: %s → %s", symlinkPath, target)
				return true
			}

			// Check if it escapes even if target doesn't match expected
			if filepath.IsAbs(target) {
				cleanExtractDir := filepath.Clean(extractDir)
				if !strings.HasPrefix(filepath.Clean(target), cleanExtractDir) {
					t.Errorf("critical issue!... absolute symlink escapes extraction dir!")
					t.Logf("  symlink: %s → %s", symlinkPath, target)
					return true
				}
			}
		}
		// if it exists but is not a symlink, that's good (attack was thwarted)...
	}

	return false
}

// createMaliciousZipWithSymlink creates a ZIP archive containing a symlink entry pointing to an arbitrary target,
// followed by a file entry that attempts to write through that symlink.
// returns the path to the created ZIP archive.
func createMaliciousZipWithSymlink(t *testing.T, tempDir, symlinkName, symlinkTarget, fileName string) string {
	t.Helper()

	maliciousZip := filepath.Join(tempDir, "malicious.zip")
	zipFile, err := os.Create(maliciousZip)
	require.NoError(t, err)
	defer zipFile.Close()

	zw := zip.NewWriter(zipFile)

	// create parent directories if the symlink is nested
	if dir := filepath.Dir(symlinkName); dir != "." {
		dirHeader := &zip.FileHeader{
			Name:   dir + "/",
			Method: zip.Store,
		}
		dirHeader.SetMode(os.ModeDir | 0755)
		_, err = zw.CreateHeader(dirHeader)
		require.NoError(t, err)
	}

	// create symlink entry pointing outside extraction directory
	// note: ZIP format stores symlinks as regular files with the target path as content
	symlinkHeader := &zip.FileHeader{
		Name:   symlinkName,
		Method: zip.Store,
	}
	symlinkHeader.SetMode(os.ModeSymlink | 0755)

	symlinkWriter, err := zw.CreateHeader(symlinkHeader)
	require.NoError(t, err)

	// write the symlink target as the file content (this is how ZIP stores symlinks)
	_, err = symlinkWriter.Write([]byte(symlinkTarget))
	require.NoError(t, err)

	// create file entry that will be written through the symlink
	if fileName != "" {
		payloadContent := []byte("MALICIOUS PAYLOAD - This should NOT be written outside extraction dir!")
		payloadHeader := &zip.FileHeader{
			Name:   fileName,
			Method: zip.Deflate,
		}
		payloadHeader.SetMode(0644)

		payloadWriter, err := zw.CreateHeader(payloadHeader)
		require.NoError(t, err)

		_, err = payloadWriter.Write(payloadContent)
		require.NoError(t, err)
	}

	require.NoError(t, zw.Close())
	require.NoError(t, zipFile.Close())

	return maliciousZip
}
